'''
Pyson schemas performs validation of complex JSON-like structures.

In all examples we assume that all contents of pyson.schema was imported, i.e., 
all examples assume

>>> from pyson.schema import *

=====
Usage
=====

Simple validators
=================

The most simple classes validate atomic objects. The most simple example is 
probably the `Cte` schema, that checks if an object is equal to some given
constant:

>>> schema = Cte('forty two')

In order to  check if some object validates with a given schema, just call one 
of the methods ``.isvalid(obj)`` or ``.validate(obj)``. The first return a 
True/False value whether the object validates or not. The second raises a 
`ValidationError` if the object is not valid.

>>> schema.isvalid('forty two')
True
>>> schema.isvalid(42)
False
>>> schema.validate('forty two')
>>> schema.validate(42)
Traceback (most recent call last):
...
ValidationError: <type 'int'> object not a <Cte "Cte('forty two')">

It is easy to create new validators for atomic types. The most simple way
is to override the `isvalid` method.

>>> class GreaterThanPi(Schema):
...     def isvalid(self, obj, **kwds):
...         try:
...             return float(obj) > 3.1415
...         except ValueError: # not a number!
...             return False

>>> GreaterThanPi().isvalid(5)
True
>>> GreaterThanPi().isvalid('some string')
False

Object validators
=================

Usually, we are interested in more complex JSON structures. The Object() schema
matches dictionary/list structures with a given predefined schema. 

>>> schema = Object({
...     'foobar': {
...         'foo': Int(), 
...         'bar': Number() }})
'''
from __future__ import absolute_import, print_function, unicode_literals, division
from future_builtins import * #@UnusedWildImport
if __name__ == '__main__':
    __package__ = b'pyson.schema' #@ReservedAssignment
    import pyson.schema #@UnusedImport

from .errors import ValidationError, NoRootError, AdaptationError
from .util import get_schemas
from .. import types
from .. import json_decode

# Hold an arbitrary reference to an object to tell that its value is undefined
class Undefined(object):
    def __str__(self):
        return '<undefined>'
undefined = Undefined()

#===============================================================================
#                             Base Schema class
#===============================================================================
class Schema(object):
    # Internal counter that tracks the order of creation of each instance
    _INSTANCE_COUNTER = 0

    # Default null value. Can be overridden in child classes
    NULL = None

    def __init__(self, default=undefined, null=undefined, label=None,
                 descr='', parent=None):
        """
        Base class for validation of JSON-like objects. 
        
        Parameters
        ----------
        
        default : JSON-like
            Default value of the field. Subclasses should generally use the 
            default value as the first positional argument. The default value
            must validate to the schema.
        null : JSON-like
            The null value for the field. It is ``None`` for most fields, but it
            might make sense to assign a different value. This value may be 
            returned in some operations if no valid is assigned to the field and
            if default does not exist. ``null`` does not need to validate to
            the schema.
        label : str
            Optional label. Used to print error messages.
        desc : str
            Object's description. Useful if one wants to introspect the schema.
        parent : Schema
            Parent Schema object in the JSON structure.
            
        Observations
        ------------
                
        The distinction between the ``null`` and ``default`` fields can be 
        subtle. When the field is absent in a JSON structure, the Schema objects
        can fill in the missing values by first querying ``default``, and if it
        does not exist, it queries ``null``.
          
        The ability to change the null value of the field is specially useful 
        in Str fields. The default behavior distinguishes "the field is empty" 
        (in which the field evaluates to None) from "the field is filled with 
        an empty string". If this distinction is not desirable, one should 
        assign default='', making empty Str fields evaluate to '' rather than 
        None.
        
        An important  difference is that the ``null`` value does not need to 
        validate, while the ``default`` value must.
        """
        if type(self) is Schema:
            raise TypeError('cannot create Schema instances, use a subclass')

        # In order to keep a consistent order across fields, this class keeps
        # a global counter thar is incremented every time a new instance of a 
        # Schema sublcass is instantiated.
        Schema._INSTANCE_COUNTER += 1
        self._ordering_index = Schema._INSTANCE_COUNTER

        # Save all parameters
        self.null = (self.NULL if null is undefined else null)
        self.label = (type(self).__name__ if label is None else label)
        self.descr = descr
        self.parent = parent

        # Save and validate the default value
        if default is not undefined:
            self.validate(default)
            self.default = default


        self.default_or_null = (self.default if hasattr(self, 'default') else self.null)

    #===========================================================================
    # Validation
    #===========================================================================
    def validate(self, obj, lazy=1, **kwds):
        """Raise a ValidationError exception if 'obj' does not conform to 
        the given JSON-schema.
        
        Parameters
        ----------
        
        obj : JSON-like
            Object to be validated.
        lazy : bool
            A zero value indicates that any missing fields will trigger a 
            ValidationError. A value of 1 (default) indicates that missing 
            obligatory fields with a ``default`` value will not trigger errors. 
            A value of 2 indicates that missing fields never trigger errors.  
            
        Examples
        --------
        
        >>> schema = Schema({'name': Str(), 'age': Int(), 'color': Str('blue') })
        
        Valid object: nothing happens
        
        >>> schema.validate({'name': 'Arthur'})
        
        Defaults are filled implicitly, but this can be overridden
        
        >>> schema.validate({'name': 'Arthur'}, lazy=False)
        Traceback (most recent call last):
         ...
        MissingKeyError: missing key: $.color
        
        Invalid object: the field 'favorite_color' is not present in the
        Schema. 
        
        >>> schema.validate({'name': 'Arthur', 'favorite_color': 'Blue'})
        Traceback (most recent call last):
         ...
        InvalidKeyError: invalid key: $.favorite_color
        
        Notes
        -----
        
        This function can be overridden in child classes, but it is often more
        convenient use the func:`pyson.schema.util.from_simple` decorator to 
        provide only a minimal implementation for this function.
        """

        if not self.isvalid(obj, lazy=lazy, **kwds):
            msg = "{tname} object not a {schname}".format(tname=type(obj), schname=self)
            raise ValidationError(msg)

    def isvalid(self, obj, lazy=1, **kwds):
        """Return True if 'obj' is valid and False otherwise. 
        
        Notes
        -----
        
        Subclasses should not override this function. See `Schema.validate` 
        instead."""

        try:
            self.validate(obj, lazy=lazy)
            return True
        except ValidationError:
            return False

    def validateroot(self, obj, **kwds):
        '''Validate only the root keys of object. 
        
        Raises NoRootError if `obj` does not have any root key.'''

        raise NoRootError('object has no root nodes')

    def isvalidroot(self, obj, **kwds):
        '''Return True/False if the root keys are valid

        Raises NoRootError if `obj` does not have any root key.'''

        try:
            self.validateroot(obj, **kwds)
            return True
        except ValidationError:
            return False

    def isvalidempty(self, lazy=1, **kwds):
        '''Return True if field can be empty.'''

        if lazy == 1:
            return hasattr(self, 'default')
        elif lazy == 2:
            return True
        elif lazy == 0:
            return False
        else:
            raise ValueError('invalid value for lazy: %s' % lazy)

    def isdefault(self, obj):
        '''Return True if ``obj`` is equal to schema's ``default`` value.'''
        try:
            return self.default == obj
        except AttributeError:
            return False

    #===========================================================================
    # Transformations
    #===========================================================================
    def copy(self, keep_parent=False):
        '''Deep copy of schema object. This method is useful in order to define
        fields with identical behavior.
        
        Parameters
        ----------
        
        keep_parent : bool
            If True, it keeps the same parent for the copy. Otherwise,
            the copy is created without a parent (default).
            
        Notes
        -----
        
        A similar, but more flexible, behavior can be obtained calling the 
        Schema object. See `Schema.__call__` for info.'''

        import copy

        # Copy object by updating its dict
        new = object.__new__(type(self))
        for k, v in self.__dict__.items():
            setattr(new, k, copy.deepcopy(v))

        # Update counter
        Schema._INSTANCE_COUNTER += 1
        new._ordering_index = Schema._INSTANCE_COUNTER

        # Save parent
        if not keep_parent:
            new.parent = None
        return new

    def adapt(self, obj, jsondecode=False, fromstring=False, bypasscheck=False, **kwds):
        '''
        Adapts an object to be compatible with the schema. 
        
        This is useful,
        for instance, to load JSON representations of objects into valid
        versions of these objects.
        
        Other common conversions are also implemented (e.g., ints to floats,
        str to unicode)
        
        **Optional arguments**
        
        The `adapt` method accepts a large number of optional arguments. Most
        of these options are recognized by a only a few Schema validators, and 
        does not affect the behavior of the others. Any option that is not 
        recognized is simply ignored.
        
        All boolean optional parameters default to 'False'
        
        inplace : bool
            For container objects, adapt them inplace (i.e., does not return
            a copy, but the object itself).
        jsondecode : bool
            Treats obj as being JSON-encoded and adapt the decoded version if 
            it does not validate.
        fromstring : bool
            Intepret `obj` as a string dump.
        truncrange : bool
            Truncate numeric values to fit inside the valid range.
        trunclen : bool
            Truncate sequence data to fit the maximum length.
        
        Examples
        --------
        
        >>> schema = Schema({'name': Str(), 'age': Int()})
        >>> schema.adapt({'name': 'Arthur', 'age': 31.0})
        {'age': 31, 'name': u'Arthur'}
        '''

        if hasattr(obj, 'adapt'):
            return obj.adapt(obj, jsondecode=jsondecode, fromstring=fromstring, **kwds)
        elif (not bypasscheck) and self.isvalid(obj, **kwds):
            return obj
        elif jsondecode:
            if isinstance(obj, types.Object) and '@type' in obj:
                res = self.json_decode(obj)
                if self.isvalid(res, **kwds):
                    return res
            elif isinstance(obj, types.Array) and obj:
                last = obj[-1]
                if isinstance(last, basestring) and last.startswith('@type:'):
                    res = self.json_decode(obj)
                    if self.isvalid(res, **kwds):
                        return res
        if fromstring and isinstance(obj, basestring):
            return self._adapt_from_string(obj, **kwds)

        if bypasscheck:
            return obj

        msg = '%s object not compatible with %s' % (type(obj), self)
        raise AdaptationError(msg)

#    def _expand_or_compress(self, is_expand, obj, **kwds):
#        '''Many objects have the same or similar behaviors under expansion and 
#        compression. The two functions can be implemented together by subclasses
#        simply overriding this method.
#        
#        The first argument ``is_expand`` tells if the operation is an expansion
#        or a compression and the other arguments are the same of the 
#        corresponding `Schema.expand` and `Schema.compress` methods.'''
#
#        # Only container objects must do something when expanding.
#        # Most objects simply return themselves.
#        if  obj == self.null:
#            return self.null
#
#        if kwds.get('validate', False):
#            self.validate(obj, **kwds)
#
#        return obj
#
#    def expand(self, obj, use_default=True, inplace=False, **kwds):
#        """Return a copy of ``obj`` with all optional empty fields assigned to 
#        their ``default`` or ``null`` values. 
#        
#        Parameters
#        ----------
#        
#        obj : JSON-like
#            Object to do expansion.
#        use_default : bool
#            If False, it will not expand default values.   
#        inplace : bool
#            If True, it will perform the expansion inplace instead of creating 
#            a copy. Immutable types are insensitive to this argument.
#        
#        Notes
#        -----
#        
#        In most fields, ``null`` is mapped to Python's None. However, container
#        fields such as Object and Array use respectively {} and [] as the 
#        default 'null' value. This can be overridden by the keyword argument 
#        'null' on object's initialization. 
#        
#        Users may find more convenient to override `Schema._expand_or_compress` 
#        method in subclasses.
#        
#        Examples
#        --------
#        
#        Expand the default value
#        
#        >>> from pyson.schema import *
#        >>> schema = Schema({'name': Str(), 'age': Int(18)})
#        >>> schema.expand({'name': 'Chips'})
#        {u'age': 18, u'name': 'Chips'}
#        
#        Fill empty entries with null
#        
#        >>> schema = Schema({'name': Str(), 'age?': Int()})
#        >>> schema.expand({'name': 'Chips'})
#        {u'age': None, u'name': 'Chips'}
#        """
#
#        return self._expand_or_compress(True, obj, **kwds)
#
#    def compress(self, obj, use_default=True, inplace=False, **kwds):
#        """Return a copy of ``obj`` omitting all optional fields that are equal
#        to their ``default`` or ``null`` values (if ``default`` does not exist).
#        
#        Parameters
#        ----------
#        
#        obj : JSON-like
#            Object to do expansion.
#        use_default : bool
#            If False, it will not expand default values.   
#        inplace : bool
#            If True, it will perform the expansion inplace instead of creating 
#            a copy. Immutable types are insensitive to this argument.
#        
#        Notes
#        -----
#        
#        Users may find more convenient to override `Schema._expand_or_compress` 
#        method in subclasses.
#        
#        Examples
#        --------
#        
#        Remove default values
#        
#        >>> from pyson.schema import *
#        >>> schema = Schema({'name': Str(), 'age': Int(18)})
#        >>> schema.compress({'name': 'Chips', 'age': 18})
#        {u'name': 'Chips'}
#        
#        Remove null
#        
#        >>> schema = Schema({'name': Str(), 'age?': Int()})
#        >>> schema.compress({'name': 'Chips', 'age': None})
#        {u'name': 'Chips'}
#        """
#
#        return self._expand_or_compress(False, obj, **kwds)
#
#    def obj_from_string(self, obj, validate=True):
#        '''Converts a string representation of ``obj`` into a string. 
#        Usually, this is a conversion from a JSON representation. 
#        
#        However, subclasses may define different serializations.'''
#
#        try:
#            obj = pyson.serialize.loads(obj)
#        except ValueError as ex:
#            msg = "%s: '%s' as %s()" % (ex, obj, type(self).__name__)
#            raise ValueError(msg)
#
#        if validate:
#            self.validate(obj)
#        return obj

    #===========================================================================
    # Magical methods
    #===========================================================================
    def __str__(self):
        tname = type(self).__name__
        if self.label == tname:
            return '<%s at 0x%s>' % (tname, id(self))
        else:
            rlabel = repr(self.label).lstrip('u')
            return "<%s %s>" % (tname, rlabel)

    def __unicode__(self):
        return unicode(str(self))

    def __and__(self, other):
        '''x.__and__(y) <==> x ^ y'''

        return self.AND_SCHEMA(self, other)

    def __or__(self, other):
        '''x.__or__(y) <==> x | y'''

        return self.OR_SCHEMA(self, other)

    def __call__(self, **kwds):
        '''
        Creates a copy of itself and initializes it using (possibly) different 
        parameters. By calling a schema object one can create a copy and 
        simultaneously reset any desired property (default value, desc, label, 
        etc). 
        
        The default value can only be set explicitly as a keyword argument.
        
        Example
        -------
        
        >>> from pyson.schema import *
        >>> v1 = Str(null='', label='Some string')
        >>> v2 = v1(label='Other string')
        >>> v1.null == v2.null
        True
        >>> print(v2.label)
        Other string
        '''

        new = self.copy()
        for attr, v in kwds.items():
            setattr(new, attr, v)
        return new

    #===========================================================================
    # Object's location within JSON structure 
    #===========================================================================
    def getpath(self):
        '''
        Location of schema within the JSON structure.
        '''

        path = []
        node = self
        while node is not None:
            path.append(node.name)
            node = node.parent
        path.pop()
        path.reverse()

        return path

#===============================================================================
# Basic generic validators
#===============================================================================
class Cte(Schema):
    '''
    Validates objects that are equal to a given constant value.
    '''

    def __init__(self, cte, null=undefined, label=None,
                            descr='', parent=None, has_default=True):
        self.cte_value = cte
        if label is None:
            if isinstance(cte, unicode):
                cte_repr = repr(cte).lstrip('u')
            else:
                cte_repr = repr(cte)
            cte_repr = cte_repr if len(cte_repr) < 20 else cte_repr[:17] + '...'
            label = 'Cte(%s)' % cte_repr
        default = (cte if has_default else undefined)
        super(Cte, self).__init__(default=default, null=null, label=label,
                                  descr=descr, parent=parent)

    def isvalid(self, obj, **kwds):
        return obj == self.cte_value

class Root(Cte):
    '''A field that must be present with a given value inside the JSON structure'''

    def __init__(self, cte, null=undefined, label=None, descr='', parent=None):
        super(Root, self).__init__(cte, null, label, descr, parent, False)

    def validateroot(self, obj, **kwds):
        self.validate(obj, **kwds)

class Optional(Schema):
    '''An optional field. (alias: Opt)'''

    def __init__(self, subfield, parent=None):
        if isinstance(subfield, Root):
            raise TypeError('cannot have an optional Root field')
        self.subfield = subfield
        self.subfield.parent = parent
        super(Optional, self).__init__()

    # null field
    def _get_(self): return self.subfield.null
    def _set_(self, value): self.subfield.null = value
    null = property(_get_, _set_)

    # label field
    def _get_(self): return self.subfield.label #@DuplicatedSignature
    def _set_(self, value): self.subfield.label = value #@DuplicatedSignature
    label = property(_get_, _set_)

    # descr field
    def _get_(self): return self.subfield.descr #@DuplicatedSignature
    def _set_(self, value): self.subfield.descr = value #@DuplicatedSignature
    descr = property(_get_, _set_)

    # parent field
    def _get_(self): return self.subfield.parent #@DuplicatedSignature
    def _set_(self, value): self.subfield.parent = value #@DuplicatedSignature
    parent = property(_get_, _set_)
    del _get_;  del _set_

    def isvalidempty(self, lazy=1, **kwds):
        return True

    def validate(self, obj, lazy=1, **kwds):
        self.subfield.validate(obj, lazy, **kwds)

    def isvalid(self, obj, lazy=1, **kwds):
        return self.subfield.isvalid(obj, lazy, **kwds)

Opt = Optional

class Anything(Schema):
    '''
    A validator that validates any object. 
    '''

    def isvalid(self, obj):
        return True

class Nothing(Schema):
    '''
    A validator that invalidates all objects.
    '''
    def isvalid(self, obj):
        return False

# Save specialized schema into the Schema class
Schema.CTE_SCHEMA = Cte

__all__ = get_schemas(globals()) + ['undefined']
if __name__ == '__main__':
    from . import *
    import doctest
    doctest.testmod(optionflags=doctest.REPORT_ONLY_FIRST_FAILURE, verbose=0)
